Нет описания

[id].tsx 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. Image,
  5. KeyboardAvoidingView,
  6. Modal,
  7. Platform,
  8. Pressable,
  9. ScrollView,
  10. StyleSheet,
  11. TextInput,
  12. View,
  13. } from 'react-native';
  14. import * as ImagePicker from 'expo-image-picker';
  15. import DateTimePicker from '@react-native-community/datetimepicker';
  16. import { ResizeMode, Video } from 'expo-av';
  17. import { useLocalSearchParams, useRouter } from 'expo-router';
  18. import { ThemedButton } from '@/components/themed-button';
  19. import { IconButton } from '@/components/icon-button';
  20. import { ThemedText } from '@/components/themed-text';
  21. import { ThemedView } from '@/components/themed-view';
  22. import { ZoomImageModal } from '@/components/zoom-image-modal';
  23. import { Colors } from '@/constants/theme';
  24. import { useColorScheme } from '@/hooks/use-color-scheme';
  25. import { useTranslation } from '@/localization/i18n';
  26. import { dbPromise, initCoreTables } from '@/services/db';
  27. type FieldRow = {
  28. id: number;
  29. name: string | null;
  30. };
  31. type CropRow = {
  32. id: number;
  33. crop_name: string | null;
  34. };
  35. type HarvestRow = {
  36. id: number;
  37. field_id: number | null;
  38. crop_id: number | null;
  39. harvested_at: string | null;
  40. quantity: number | null;
  41. unit: string | null;
  42. field_name: string | null;
  43. crop_name: string | null;
  44. };
  45. type SaleRow = {
  46. id: number;
  47. field_id: number | null;
  48. crop_id: number | null;
  49. harvest_id: number | null;
  50. sold_at: string | null;
  51. quantity: number | null;
  52. unit: string | null;
  53. price: number | null;
  54. buyer: string | null;
  55. notes: string | null;
  56. };
  57. type MediaRow = {
  58. uri: string | null;
  59. };
  60. export default function SaleDetailScreen() {
  61. const { t } = useTranslation();
  62. const router = useRouter();
  63. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  64. const saleId = Number(Array.isArray(id) ? id[0] : id);
  65. const theme = useColorScheme() ?? 'light';
  66. const palette = Colors[theme];
  67. const [loading, setLoading] = useState(true);
  68. const [status, setStatus] = useState('');
  69. const [fields, setFields] = useState<FieldRow[]>([]);
  70. const [crops, setCrops] = useState<CropRow[]>([]);
  71. const [harvests, setHarvests] = useState<HarvestRow[]>([]);
  72. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  73. const [cropModalOpen, setCropModalOpen] = useState(false);
  74. const [harvestModalOpen, setHarvestModalOpen] = useState(false);
  75. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  76. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  77. const [selectedHarvestId, setSelectedHarvestId] = useState<number | null>(null);
  78. const [saleDate, setSaleDate] = useState('');
  79. const [showSalePicker, setShowSalePicker] = useState(false);
  80. const [quantity, setQuantity] = useState('');
  81. const [unit, setUnit] = useState('');
  82. const [currency, setCurrency] = useState('THB');
  83. const [price, setPrice] = useState('');
  84. const [buyer, setBuyer] = useState('');
  85. const [notes, setNotes] = useState('');
  86. const [mediaUris, setMediaUris] = useState<string[]>([]);
  87. const [activeUri, setActiveUri] = useState<string | null>(null);
  88. const [errors, setErrors] = useState<{ field?: string; crop?: string; quantity?: string }>({});
  89. const [zoomUri, setZoomUri] = useState<string | null>(null);
  90. const [saving, setSaving] = useState(false);
  91. const [showSaved, setShowSaved] = useState(false);
  92. useEffect(() => {
  93. let isActive = true;
  94. async function loadSale() {
  95. try {
  96. await initCoreTables();
  97. const db = await dbPromise;
  98. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  99. const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
  100. const profileRow = await db.getFirstAsync<{ currency: string | null }>(
  101. 'SELECT currency FROM user_profile WHERE id = 1;'
  102. );
  103. const harvestRows = await db.getAllAsync<HarvestRow>(
  104. `SELECT h.id, h.field_id, h.crop_id, h.harvested_at, h.quantity, h.unit,
  105. f.name as field_name, c.crop_name as crop_name
  106. FROM harvests h
  107. LEFT JOIN fields f ON f.id = h.field_id
  108. LEFT JOIN crops c ON c.id = h.crop_id
  109. ORDER BY h.harvested_at DESC;`
  110. );
  111. const rows = await db.getAllAsync<SaleRow>(
  112. `SELECT id, field_id, crop_id, harvest_id, sold_at, quantity, unit, price, buyer, notes
  113. FROM sales WHERE id = ? LIMIT 1;`,
  114. saleId
  115. );
  116. if (!isActive) return;
  117. setFields(fieldRows);
  118. setCrops(cropRows);
  119. setCurrency(profileRow?.currency ?? 'THB');
  120. setHarvests(harvestRows);
  121. const sale = rows[0];
  122. if (!sale) {
  123. setStatus(t('sales.empty'));
  124. setLoading(false);
  125. return;
  126. }
  127. setSelectedFieldId(sale.field_id ?? null);
  128. setSelectedCropId(sale.crop_id ?? null);
  129. setSelectedHarvestId(sale.harvest_id ?? null);
  130. setSaleDate(sale.sold_at ?? '');
  131. setQuantity(sale.quantity !== null ? String(sale.quantity) : '');
  132. setUnit(sale.unit ?? '');
  133. setPrice(sale.price !== null ? String(sale.price) : '');
  134. setBuyer(sale.buyer ?? '');
  135. setNotes(sale.notes ?? '');
  136. const mediaRows = await db.getAllAsync<MediaRow>(
  137. 'SELECT uri FROM sale_media WHERE sale_id = ? ORDER BY created_at ASC;',
  138. saleId
  139. );
  140. const media = uniqueMediaUris(mediaRows.map((row) => row.uri).filter(Boolean) as string[]);
  141. setMediaUris(media);
  142. setActiveUri(media[0] ?? null);
  143. } catch (error) {
  144. if (isActive) setStatus(`Error: ${String(error)}`);
  145. } finally {
  146. if (isActive) setLoading(false);
  147. }
  148. }
  149. loadSale();
  150. return () => {
  151. isActive = false;
  152. };
  153. }, [saleId, t]);
  154. const selectedField = useMemo(
  155. () => fields.find((item) => item.id === selectedFieldId),
  156. [fields, selectedFieldId]
  157. );
  158. const selectedCrop = useMemo(
  159. () => crops.find((item) => item.id === selectedCropId),
  160. [crops, selectedCropId]
  161. );
  162. const selectedHarvest = useMemo(
  163. () => harvests.find((item) => item.id === selectedHarvestId),
  164. [harvests, selectedHarvestId]
  165. );
  166. const inputStyle = [
  167. styles.input,
  168. {
  169. borderColor: palette.border,
  170. backgroundColor: palette.input,
  171. color: palette.text,
  172. },
  173. ];
  174. const unitPresets = ['kg', 'g', 'ton', 'pcs'];
  175. async function handleUpdate() {
  176. const parsedQuantity = quantity.trim() ? Number(quantity) : null;
  177. const nextErrors: { field?: string; crop?: string; quantity?: string } = {};
  178. if (!selectedFieldId) nextErrors.field = t('sales.fieldRequired');
  179. if (!selectedCropId) nextErrors.crop = t('sales.cropRequired');
  180. if (quantity.trim() && !Number.isFinite(parsedQuantity)) {
  181. nextErrors.quantity = t('sales.quantityInvalid');
  182. }
  183. setErrors(nextErrors);
  184. if (Object.keys(nextErrors).length > 0) return;
  185. try {
  186. setSaving(true);
  187. const db = await dbPromise;
  188. const now = new Date().toISOString();
  189. await db.runAsync(
  190. 'UPDATE sales SET field_id = ?, crop_id = ?, harvest_id = ?, sold_at = ?, quantity = ?, unit = ?, price = ?, buyer = ?, notes = ? WHERE id = ?;',
  191. selectedFieldId,
  192. selectedCropId,
  193. selectedHarvestId,
  194. saleDate || null,
  195. parsedQuantity,
  196. unit.trim() || null,
  197. price.trim() ? Number(price) : null,
  198. buyer.trim() || null,
  199. notes.trim() || null,
  200. saleId
  201. );
  202. await db.runAsync('DELETE FROM sale_media WHERE sale_id = ?;', saleId);
  203. const mediaToInsert = uniqueMediaUris(mediaUris);
  204. for (const uri of mediaToInsert) {
  205. await db.runAsync(
  206. 'INSERT INTO sale_media (sale_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  207. saleId,
  208. uri,
  209. isVideoUri(uri) ? 'video' : 'image',
  210. now
  211. );
  212. }
  213. setStatus(t('sales.saved'));
  214. setShowSaved(true);
  215. setTimeout(() => {
  216. setShowSaved(false);
  217. setStatus('');
  218. }, 1800);
  219. } catch (error) {
  220. setStatus(`Error: ${String(error)}`);
  221. } finally {
  222. setSaving(false);
  223. }
  224. }
  225. function confirmDelete() {
  226. Alert.alert(
  227. t('sales.deleteTitle'),
  228. t('sales.deleteMessage'),
  229. [
  230. { text: t('sales.cancel'), style: 'cancel' },
  231. {
  232. text: t('sales.delete'),
  233. style: 'destructive',
  234. onPress: async () => {
  235. const db = await dbPromise;
  236. await db.runAsync('DELETE FROM sale_media WHERE sale_id = ?;', saleId);
  237. await db.runAsync('DELETE FROM sales WHERE id = ?;', saleId);
  238. router.back();
  239. },
  240. },
  241. ]
  242. );
  243. }
  244. const harvestLabel = selectedHarvest
  245. ? `${selectedHarvest.field_name || ''} ${selectedHarvest.crop_name || ''}, ${selectedHarvest.quantity ?? ''} ${selectedHarvest.unit ?? ''}, ${selectedHarvest.harvested_at ?? ''}`.trim()
  246. : t('sales.selectHarvest');
  247. if (loading) {
  248. return (
  249. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  250. <ThemedText>{t('sales.loading')}</ThemedText>
  251. </ThemedView>
  252. );
  253. }
  254. return (
  255. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  256. <KeyboardAvoidingView
  257. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  258. style={styles.keyboardAvoid}>
  259. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  260. <ThemedText type="title">{t('sales.edit')}</ThemedText>
  261. {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
  262. <ThemedText>{t('sales.field')}</ThemedText>
  263. <ThemedButton
  264. title={selectedField?.name || t('sales.selectField')}
  265. onPress={() => setFieldModalOpen(true)}
  266. variant="secondary"
  267. />
  268. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  269. <ThemedText>{t('sales.crop')}</ThemedText>
  270. <ThemedButton
  271. title={selectedCrop?.crop_name || t('sales.selectCrop')}
  272. onPress={() => setCropModalOpen(true)}
  273. variant="secondary"
  274. />
  275. {errors.crop ? <ThemedText style={styles.errorText}>{errors.crop}</ThemedText> : null}
  276. <ThemedText>{t('sales.harvest')}</ThemedText>
  277. <ThemedButton
  278. title={harvestLabel || t('sales.selectHarvest')}
  279. onPress={() => setHarvestModalOpen(true)}
  280. variant="secondary"
  281. />
  282. <ThemedText>{t('sales.date')}</ThemedText>
  283. <Pressable onPress={() => setShowSalePicker(true)} style={styles.dateInput}>
  284. <ThemedText style={styles.dateValue}>
  285. {saleDate || t('sales.datePlaceholder')}
  286. </ThemedText>
  287. </Pressable>
  288. {showSalePicker ? (
  289. <DateTimePicker
  290. value={saleDate ? new Date(saleDate) : new Date()}
  291. mode="date"
  292. onChange={(event, date) => {
  293. setShowSalePicker(false);
  294. if (date) setSaleDate(toDateOnly(date));
  295. }}
  296. />
  297. ) : null}
  298. <ThemedText>{t('sales.quantity')}</ThemedText>
  299. <TextInput
  300. value={quantity}
  301. onChangeText={(value) => {
  302. setQuantity(value);
  303. if (errors.quantity) setErrors((prev) => ({ ...prev, quantity: undefined }));
  304. }}
  305. placeholder={t('sales.quantityPlaceholder')}
  306. placeholderTextColor={palette.placeholder}
  307. style={inputStyle}
  308. keyboardType="decimal-pad"
  309. />
  310. {errors.quantity ? <ThemedText style={styles.errorText}>{errors.quantity}</ThemedText> : null}
  311. <ThemedText>{t('sales.unit')}</ThemedText>
  312. <View style={styles.chipRow}>
  313. {unitPresets.map((preset) => {
  314. const label = t(`units.${preset}`);
  315. const isActive = unit === label || unit === preset;
  316. return (
  317. <Pressable
  318. key={preset}
  319. style={[styles.chip, isActive ? styles.chipActive : null]}
  320. onPress={() => setUnit(label)}>
  321. <ThemedText style={styles.chipText}>{label}</ThemedText>
  322. </Pressable>
  323. );
  324. })}
  325. </View>
  326. <TextInput
  327. value={unit}
  328. onChangeText={setUnit}
  329. placeholder={t('sales.unitPlaceholder')}
  330. placeholderTextColor={palette.placeholder}
  331. style={inputStyle}
  332. />
  333. <ThemedText>{t('sales.price')} ({currency})</ThemedText>
  334. <TextInput
  335. value={price}
  336. onChangeText={setPrice}
  337. placeholder={t('sales.pricePlaceholder')}
  338. placeholderTextColor={palette.placeholder}
  339. style={inputStyle}
  340. keyboardType="decimal-pad"
  341. />
  342. <ThemedText>{t('sales.buyer')}</ThemedText>
  343. <TextInput
  344. value={buyer}
  345. onChangeText={setBuyer}
  346. placeholder={t('sales.buyerPlaceholder')}
  347. placeholderTextColor={palette.placeholder}
  348. style={inputStyle}
  349. />
  350. <ThemedText>{t('sales.notes')}</ThemedText>
  351. <TextInput
  352. value={notes}
  353. onChangeText={setNotes}
  354. placeholder={t('sales.notesPlaceholder')}
  355. placeholderTextColor={palette.placeholder}
  356. style={inputStyle}
  357. multiline
  358. />
  359. <ThemedText>{t('sales.addMedia')}</ThemedText>
  360. {normalizeMediaUri(activeUri) ? (
  361. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  362. <Video
  363. source={{ uri: normalizeMediaUri(activeUri) as string }}
  364. style={styles.mediaPreview}
  365. useNativeControls
  366. resizeMode={ResizeMode.CONTAIN}
  367. />
  368. ) : (
  369. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  370. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  371. </Pressable>
  372. )
  373. ) : (
  374. <ThemedText style={styles.photoPlaceholder}>{t('sales.noPhoto')}</ThemedText>
  375. )}
  376. {mediaUris.length > 0 ? (
  377. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  378. {mediaUris.map((uri) => (
  379. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  380. {isVideoUri(uri) ? (
  381. <View style={styles.videoThumb}>
  382. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  383. </View>
  384. ) : (
  385. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  386. )}
  387. <Pressable
  388. style={styles.mediaRemove}
  389. onPress={(event) => {
  390. event.stopPropagation();
  391. setMediaUris((prev) => {
  392. const next = prev.filter((item) => item !== uri);
  393. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  394. return next;
  395. });
  396. }}>
  397. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  398. </Pressable>
  399. </Pressable>
  400. ))}
  401. </ScrollView>
  402. ) : null}
  403. <View style={styles.photoRow}>
  404. <ThemedButton
  405. title={t('sales.pickFromGallery')}
  406. onPress={() =>
  407. handlePickMedia((uris) => {
  408. if (uris.length === 0) return;
  409. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  410. setActiveUri((prev) => prev ?? uris[0]);
  411. })
  412. }
  413. variant="secondary"
  414. />
  415. <ThemedButton
  416. title={t('sales.takeMedia')}
  417. onPress={() =>
  418. handleTakeMedia((uri) => {
  419. if (!uri) return;
  420. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  421. setActiveUri((prev) => prev ?? uri);
  422. })
  423. }
  424. variant="secondary"
  425. />
  426. </View>
  427. <View style={styles.actions}>
  428. <IconButton
  429. name="trash"
  430. onPress={confirmDelete}
  431. accessibilityLabel={t('sales.delete')}
  432. variant="danger"
  433. />
  434. <View style={styles.updateGroup}>
  435. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('sales.saved')}</ThemedText> : null}
  436. <ThemedButton
  437. title={saving ? t('sales.saving') : t('sales.update')}
  438. onPress={handleUpdate}
  439. disabled={saving}
  440. />
  441. </View>
  442. </View>
  443. </ScrollView>
  444. </KeyboardAvoidingView>
  445. <Modal transparent visible={fieldModalOpen} animationType="fade">
  446. <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
  447. <View style={styles.modalCard}>
  448. <ThemedText type="subtitle">{t('sales.selectField')}</ThemedText>
  449. <ScrollView style={styles.modalList}>
  450. {fields.map((item) => (
  451. <Pressable
  452. key={item.id}
  453. style={styles.modalItem}
  454. onPress={() => {
  455. setSelectedFieldId(item.id);
  456. setFieldModalOpen(false);
  457. }}>
  458. <ThemedText>{item.name || t('sales.noField')}</ThemedText>
  459. </Pressable>
  460. ))}
  461. </ScrollView>
  462. </View>
  463. </Pressable>
  464. </Modal>
  465. <Modal transparent visible={cropModalOpen} animationType="fade">
  466. <Pressable style={styles.modalBackdrop} onPress={() => setCropModalOpen(false)}>
  467. <View style={styles.modalCard}>
  468. <ThemedText type="subtitle">{t('sales.selectCrop')}</ThemedText>
  469. <ScrollView style={styles.modalList}>
  470. {crops.map((item) => (
  471. <Pressable
  472. key={item.id}
  473. style={styles.modalItem}
  474. onPress={() => {
  475. setSelectedCropId(item.id);
  476. setCropModalOpen(false);
  477. }}>
  478. <ThemedText>{item.crop_name || t('sales.noCrop')}</ThemedText>
  479. </Pressable>
  480. ))}
  481. </ScrollView>
  482. </View>
  483. </Pressable>
  484. </Modal>
  485. <Modal transparent visible={harvestModalOpen} animationType="fade">
  486. <Pressable style={styles.modalBackdrop} onPress={() => setHarvestModalOpen(false)}>
  487. <View style={styles.modalCard}>
  488. <ThemedText type="subtitle">{t('sales.selectHarvest')}</ThemedText>
  489. <ScrollView style={styles.modalList}>
  490. {harvests.map((item) => (
  491. <Pressable
  492. key={item.id}
  493. style={styles.modalItem}
  494. onPress={() => {
  495. setSelectedHarvestId(item.id);
  496. setSelectedFieldId(item.field_id ?? null);
  497. setSelectedCropId(item.crop_id ?? null);
  498. setHarvestModalOpen(false);
  499. }}>
  500. <ThemedText>
  501. {item.field_name || ''} {item.crop_name || ''}, {item.quantity ?? ''} {item.unit ?? ''}, {item.harvested_at ?? ''}
  502. </ThemedText>
  503. </Pressable>
  504. ))}
  505. </ScrollView>
  506. </View>
  507. </Pressable>
  508. </Modal>
  509. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  510. </ThemedView>
  511. );
  512. }
  513. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  514. const result = await ImagePicker.launchImageLibraryAsync({
  515. mediaTypes: getMediaTypes(),
  516. quality: 1,
  517. allowsMultipleSelection: true,
  518. selectionLimit: 0,
  519. });
  520. if (result.canceled) return;
  521. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  522. if (uris.length === 0) return;
  523. onAdd(uris);
  524. }
  525. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  526. const permission = await ImagePicker.requestCameraPermissionsAsync();
  527. if (!permission.granted) {
  528. return;
  529. }
  530. const result = await ImagePicker.launchCameraAsync({
  531. mediaTypes: getMediaTypes(),
  532. quality: 1,
  533. });
  534. if (result.canceled) return;
  535. const asset = result.assets[0];
  536. onAdd(asset.uri);
  537. }
  538. function getMediaTypes() {
  539. const mediaType = (ImagePicker as {
  540. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  541. }).MediaType;
  542. const imageType = mediaType?.Image ?? mediaType?.Images;
  543. const videoType = mediaType?.Video ?? mediaType?.Videos;
  544. if (imageType && videoType) {
  545. return [imageType, videoType];
  546. }
  547. return imageType ?? videoType ?? ['images', 'videos'];
  548. }
  549. function isVideoUri(uri: string) {
  550. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  551. }
  552. function normalizeMediaUri(uri?: string | null) {
  553. if (typeof uri !== 'string') return null;
  554. const trimmed = uri.trim();
  555. return trimmed ? trimmed : null;
  556. }
  557. function uniqueMediaUris(uris: string[]) {
  558. const seen = new Set<string>();
  559. const result: string[] = [];
  560. for (const uri of uris) {
  561. if (!uri || seen.has(uri)) continue;
  562. seen.add(uri);
  563. result.push(uri);
  564. }
  565. return result;
  566. }
  567. function toDateOnly(date: Date) {
  568. return date.toISOString().slice(0, 10);
  569. }
  570. const styles = StyleSheet.create({
  571. container: {
  572. flex: 1,
  573. },
  574. keyboardAvoid: {
  575. flex: 1,
  576. },
  577. content: {
  578. padding: 16,
  579. gap: 10,
  580. paddingBottom: 40,
  581. },
  582. input: {
  583. borderRadius: 10,
  584. borderWidth: 1,
  585. paddingHorizontal: 12,
  586. paddingVertical: 10,
  587. fontSize: 15,
  588. },
  589. errorText: {
  590. color: '#C0392B',
  591. fontSize: 12,
  592. },
  593. dateInput: {
  594. borderRadius: 10,
  595. borderWidth: 1,
  596. borderColor: '#B9B9B9',
  597. paddingHorizontal: 12,
  598. paddingVertical: 10,
  599. },
  600. dateValue: {
  601. opacity: 0.7,
  602. },
  603. mediaPreview: {
  604. width: '100%',
  605. height: 220,
  606. borderRadius: 12,
  607. backgroundColor: '#1C1C1C',
  608. },
  609. photoRow: {
  610. flexDirection: 'row',
  611. gap: 8,
  612. },
  613. actions: {
  614. marginTop: 12,
  615. flexDirection: 'row',
  616. justifyContent: 'space-between',
  617. alignItems: 'center',
  618. gap: 10,
  619. },
  620. photoPlaceholder: {
  621. opacity: 0.6,
  622. },
  623. mediaStrip: {
  624. marginTop: 6,
  625. },
  626. mediaChip: {
  627. width: 72,
  628. height: 72,
  629. borderRadius: 10,
  630. marginRight: 8,
  631. overflow: 'hidden',
  632. backgroundColor: '#E6E1D4',
  633. alignItems: 'center',
  634. justifyContent: 'center',
  635. },
  636. mediaThumb: {
  637. width: '100%',
  638. height: '100%',
  639. },
  640. videoThumb: {
  641. width: '100%',
  642. height: '100%',
  643. backgroundColor: '#1C1C1C',
  644. alignItems: 'center',
  645. justifyContent: 'center',
  646. },
  647. videoThumbText: {
  648. color: '#FFFFFF',
  649. fontSize: 18,
  650. fontWeight: '700',
  651. },
  652. mediaRemove: {
  653. position: 'absolute',
  654. top: 4,
  655. right: 4,
  656. width: 18,
  657. height: 18,
  658. borderRadius: 9,
  659. backgroundColor: 'rgba(0,0,0,0.6)',
  660. alignItems: 'center',
  661. justifyContent: 'center',
  662. },
  663. mediaRemoveText: {
  664. color: '#FFFFFF',
  665. fontSize: 12,
  666. lineHeight: 14,
  667. fontWeight: '700',
  668. },
  669. updateGroup: {
  670. flexDirection: 'row',
  671. alignItems: 'center',
  672. gap: 8,
  673. },
  674. inlineToastText: {
  675. fontWeight: '700',
  676. fontSize: 12,
  677. },
  678. chipRow: {
  679. flexDirection: 'row',
  680. flexWrap: 'wrap',
  681. gap: 8,
  682. marginBottom: 8,
  683. },
  684. chip: {
  685. paddingHorizontal: 12,
  686. paddingVertical: 6,
  687. borderRadius: 999,
  688. borderWidth: 1,
  689. borderColor: '#D9D1C2',
  690. backgroundColor: '#F8F6F0',
  691. },
  692. chipActive: {
  693. backgroundColor: '#DDE8DA',
  694. borderColor: '#88A68F',
  695. },
  696. chipText: {
  697. fontSize: 13,
  698. },
  699. modalBackdrop: {
  700. flex: 1,
  701. backgroundColor: 'rgba(0,0,0,0.4)',
  702. justifyContent: 'center',
  703. padding: 24,
  704. },
  705. modalCard: {
  706. borderRadius: 14,
  707. backgroundColor: '#FFFFFF',
  708. padding: 16,
  709. gap: 10,
  710. maxHeight: '80%',
  711. },
  712. modalList: {
  713. maxHeight: 300,
  714. },
  715. modalItem: {
  716. paddingVertical: 10,
  717. },
  718. });